fix(runtime): type the protocol handles in the ui, meta and mcp domains - #16984
Conversation
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 134691f367d55c5069be883167b1ee9f185880b9 && git checkout 134691f367d55c5069be883167b1ee9f185880b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a72b6865f8ad6afde935aa1c229f1dfdfdf87f4c 743f1d4a219d7ed9f17ab6f900ff36695832b5c5 && git checkout -B drift-repro a72b6865f8ad6afde935aa1c229f1dfdfdf87f4c && git merge --no-ff 743f1d4a219d7ed9f17ab6f900ff36695832b5c5
node scripts/docs-audit/affected-docs.mjs --json a72b6865f8ad6afde935aa1c229f1dfdfdf87f4c
|
PM review — accepted, armingCI. 33 check-run rows, all terminal, 0 red. Every Clause-② — exit 0, both carriers agree, no widening tell. ⭐ The negative leg is the deliverable, and it is the real thingI dispatched this with one demand above the others: ⛔ a green Leg B, with the direction predicted before running: reverting the three domain files to Leg A guards the guard: injecting one directive on a line with no error moved the on-disk marker count 0 → 2, changed the blob hash, and turned the typecheck red naming the file. So the 12 directives are genuinely evaluated rather than a phantom check. Both legs ran from a committed state with trapped restores proved by empty ⭐ Both red lines verified by me, from the file rather than the body
⭐ Three pieces of honesty worth naming, because each was optional
⭐ The latent asymmetry the typing surfaced is the clearest argument for the card:
|
Fixes #15238
The
/ui,/metaand/mcpdispatcher domains now reach theprotocolservice through a typed handle, applying the shape #13598 landed indomains/packages.ts(PR #15215). Behaviour is unchanged on every route; what changes is that a misspelt key or a misspelt verb is a compile error instead of a silent no-op.Note on spelling: TypeScript generics are written with parentheses below —
Pick(MetadataProtocol, 'getUiView')— because the angle-bracket form is a shape this body's sanitizer rewrites. The code uses real angle brackets.The instrument, re-measured on this branch's base
Re-run at
1008be3be5(this branch's merge base), per non-test file underpackages/runtime/src, with the card's own two greps:resolveService(…, 'protocol')sitesanycasts on the handledomains/meta.tsdomains/mcp.tsdomains/ui.tsdomains/packages.ts(after #13598)The
packages.tsrow is the CONTROL — the same instrument on the file that was already repaired, whose single remaining site is the one inside its narrowing helper. Without it the other three counts say nothing about whether the shape is repairable.Two readings the anchor produced that judgment had to correct, recorded because a count is not a conclusion:
packages.ts, not 1. One of them is prose — the sentence "deps.resolveService(context, 'protocol')answersany" inside that file's own doc comment. The call-site count is 1, as the card says.action-execution.ts(1 site) andhttp-dispatcher.ts(1 site), neither of which appears in the card's four-row table although both are non-test files underpackages/runtime/src. Not touched here — see acceptance notes.Sized per file, because the 9+1+1 site count is not the size
domains/ui.ts— the cheapest, and the clearest. One handle, one call.getUiViewandGetUiViewRequestare both declared, and the call compiled against neither. Now typed viaUiDomainProtocol, aPartialof aPickfromMetadataProtocolnaminggetUiView, resolved through a one-lineresolveProtocolhelper. What it buys: the two request keys, the verb spelling, and the closedtypeset ('list' | 'form').domains/mcp.ts— half-done, and the narrowest win of the three. This file already declaredMcpMergedMetadataReadfor its merged-read seam, andreadMergedSkillRowsalready types its ownprotocolparameter — so everything downstream of the second handle was already compiled. Theanybought nothing there and cost only at the resolve point itself, where any verb spelt any way could be reached. The change is the annotation on that one line. Measured honestly: no new request literal becomes checked; the win is that the resolve site can no longer originate an unchecked call.domains/meta.ts— the largest diff, and the win really is narrower than inpackages.ts. Nine sites collapse onto oneresolveProtocolhelper and four(protocol as any)casts are deleted. Of the nine literals:getMetaItemLayered(was behind a cast),getMetaItemat three call sites,getMetaItems, andsaveMetaItem, whosewriteFace: 'meta-dispatch'is a declared closed-set member.getMetaTypes({})) — verb name only.anyrequest —listDrafts,migrateStoredMetadata,getProjectId.@objectstack/metadata-protocoltypes these inline on its implementation class and exports nothing for them, so a structural request type written here would be a private restatement nothing verifies. The gap stays greppable; what the entries buy is the verb NAME.One latent asymmetry the typing surfaced
The object-read branch asked
typeof protocol.getMetaItem === 'function'with noprotocol &&, while its!scopedtwin three lines below has always carried one. Typing the handle turned that into TS18048 twice. Fixed by spelling the guard out to match its twin — behaviour-identical, sincescopedderives fromprotocol?.getProjectId/protocol?.environmentIdand can only be true when the handle is there. Theanycast is precisely what let two sibling guards drift apart in spelling.The two red lines
Partial(...)even where the upstream declaration is already optional, so the invariant holds here even if the spec is ever tightened. The type answers "is this key declared?"; the probe answers "did THIS host bring the verb?".packages/specis touched.ServiceSlotContractsis unchanged; the narrowing is entirely consumer-side. Delivered diff is 5 files, all underpackages/runtime/plus one changeset.Proving the type bites — the negative leg, not "it still compiles"
New pin at
packages/runtime/src/domains/domain-protocol-handle-typing.test.ts, modelled onpackages-protocol-handle-typing.test.ts. 12@ts-expect-errordirectives; if a seam regresses toanyeach stops matching a diagnostic and becomes TS2578.Leg A — the directives are evaluated at all (guarding against a phantom check). Injected one directive on a line that has no error; marker count on disk went 0 to 2, blob hash changed, and
pnpm --filter @objectstack/runtime typecheckwent red naming the file: "1 type error(s) in a file the ledger does not cover". Restored;git diff HEAD --statandgit status --porcelainboth empty.Leg B — reverse verification, direction predicted before running. Predicted: red, TS2578 on all 12 directive lines plus import errors for the three vanished types. Reverted the three domain files to
1008be3be5(blob hashes verified equal to the base blobs), then rawtsc --noEmit -p tsconfig.test.json:Both legs ran from a committed state with a
trap ... EXIT INT TERMrestore on absolute paths, and both restores were verified by emptygit diff HEADplus emptygit status --porcelain, never by an exit code.Verification
pnpm --filter @objectstack/runtime test— 248 files / 3509 tests passed.pnpm --filter @objectstack/runtime typecheck— clean (tsc --noEmitpluscheck:test-typecheck; the new pin carries notest-typecheck-debt.jsonentry, so any unexpected error in it is red on arrival).pnpm --filter '@objectstack/runtime^...' build— dependency closure green before any judgment was read.pnpm lint(eslint . --no-inline-config, the whole repo, not a narrowed subset) — exit 0, 1m55s, at743f1d4a.scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 55 green. The other two —check:dual-build-cjs-loadsandcheck:type-check-debt— exited 3,PREREQUISITE NOT MET, which both gates print as "NOT a pass" and "NOT a finding: nothing was measured". Both want a full-workspace build; the missingdist/directories they name belong to 37 packages this diff never touches, and CI builds the closure before running them.Changeset: measured, not assumed
skip-changesetwould have been wrong.packages/runtimepublishesfiles: ["dist", "README.md", "CHANGELOG.md"]and declares nobintarget. Built both trees with--forceand compared every shipped byte:Positive control:
createUiDomainis present in both trees' emitted bytes, so the search fires. The runtime bytes move becauseresolveProtocolis a real emitted function and the guard gained a real term — this is not a type-only change. The published type surface does not move:index.d.tsis byte-identical, because the dispatcher domains are not re-exported from the package index. Apatchchangeset is included.An earlier attempt at this measurement was void and is reported rather than buried: reverting four paths at once failed atomically, because the new pin file has no version at the base commit, so nothing was reverted and both sides of the comparison were the same tree. It read
SAMEon all six files. Redone with the three shipped files only.Docs drift
scripts/docs-audit/affected-docs.mjsnames 21 pages, and itsdiffBaseresolves to1008be3be5— identical to this branch's merge base, so there is no base-mismatch delta to measure. Every anchor it fired on is a name this diff introduces (UiDomainProtocol,MetaDomainProtocol,McpMergedMetadataRead,resolveProtocol) or a protocol verb spelled inside the newPick(...)lists — vocabulary those pages already documented, not behaviour this change alters.Hand-swept
content/with a live positive control rather than trusting that:getUiViewreturns 2 hits (control fires), whileresolveService,ServiceSlotContracts,protocol as any,PackagesDomainProtocol,resolveProtocolandMcpMergedMetadataReadreturn 0 each. No hand-written page describes this seam.content/docs/kernel/services-checklist.mdx, the one page that documents the/uiroute callinggetUiViewon theprotocolservice and answering 501 when it is absent, stays true — that 501 is pinned by section 2 of the new test. No doc edited;content/docs/releases/untouched.Acceptance notes (not filed)
packages/runtime/src/action-execution.ts(1 site) andpackages/runtime/src/http-dispatcher.ts(1 site) reach the same slot through the same untyped seam and are absent from the card's four-row table, though both match its instrument and both are non-test files underpackages/runtime/src. Observation-grade like the parent card — no defect measured, nothing declared is violated, no authoring trap — so noted here rather than filed. The seat that carries the template to those two files is the natural successor.protocol as any|protocol: any) does not matchresolveService(_context, 'protocol') as any, the spelling used at onemeta.tssite, so the published cast count of 4 was one short of the 5 casts actually present. Both numbers are reproduced above as the card wrote them.Clause-②: no
Re-derived from the DELIVERED diff, not copied from the claim comment. The diff relaxes no accepted set, widens no schema, adds no closed-set member or registry entry, withdraws no capability and migrates no stored shape. It adds one
export type(McpMergedMetadataRead, so the pin beside it can name the type), which is not a published export:packages/runtime's index does not re-exportdomains/, and the byte-identicaldist/index.d.tsabove is the independent confirmation.node scripts/pm/check-widening-tells.mjs --declaration no --diff (delivered)exits 0 with no tell on any declared surface.Generated by Claude Code